spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound, permanentRedirect } from 'next/navigation';4import { ExternalLink } from 'lucide-react';5import { GraphLink } from '@/components/graph/graph-link';6import { PageHeader, Section, KV, Note } from '@/components/ui/section';7import { Badge, ClaimBadge, MatchBadge, StatusBadge } from '@/components/ui/badge';8import { EmptyState } from '@/components/ui/empty-state';9import { Freshness } from '@/components/ui/freshness';10import { SourceBadge } from '@/components/ui/source-badge';11import { JsonView } from '@/components/ui/json-view';12import { PublicationList } from '@/components/data/publication-list';13import { Pager } from '@/components/ui/pager';14import { getTrialByNct, trialConditionsFor, trialInterventionsFor, trialLocationsByCountry } from '@/lib/queries/trials';15import { publicationsForTrial, publicationsForTrialCount, PUBLICATION_PAGE_SIZE } from '@/lib/queries/publications';16import { fmtDate, fmtInt, humanize, phaseLabel } from '@/lib/format';17import { pageInfo } from '@/lib/pagination';18import { int, type SP } from '@/lib/search-params';1920export const revalidate = 3600;2122export async function generateMetadata({ params }: { params: Promise<{ nct: string }> }): Promise<Metadata> {23 const t = await getTrialByNct((await params).nct);24 return t ? { title: `${t.nct_id} — ${t.brief_title}`, description: t.brief_summary ? t.brief_summary.slice(0, 160) : `${t.nct_id}: status, phase, conditions, interventions, locations and references.`, alternates: { canonical: `/trial/${t.nct_id}` } } : { title: 'Trial' };25}2627export default async function TrialPage({ params, searchParams }: { params: Promise<{ nct: string }>; searchParams: Promise<SP> }) {28 const { nct } = await params;29 const t = await getTrialByNct(nct);30 if (!t) notFound();31 if (t.nct_id !== nct) permanentRedirect(`/trial/${t.nct_id}`);32 const sp = await searchParams;33 const pubTotal = await publicationsForTrialCount(t.nct_id);34 const pp = pageInfo(int(sp, 'pPage', 1, 1, 100_000), PUBLICATION_PAGE_SIZE, pubTotal);35 const [conditions, interventions, locations, pubs] = await Promise.all([trialConditionsFor(t.id), trialInterventionsFor(t.id), trialLocationsByCountry(t.id), pubTotal ? publicationsForTrial(t.nct_id, { page: pp.page, pageSize: pp.pageSize }) : Promise.resolve([])]);36 const elig = t.eligibility ?? {};37 const eligText = typeof elig.criteria === 'string' ? elig.criteria : typeof elig.eligibilityCriteria === 'string' ? elig.eligibilityCriteria : null;3839 return (40 <article>41 <PageHeader kicker={`Clinical trial · ${t.study_type ? humanize(t.study_type) : 'study'}`} title={t.brief_title} lede={t.official_title && t.official_title !== t.brief_title ? t.official_title : undefined}>42 <div className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]">43 <span className="ci-mono text-ink-2">{t.nct_id}</span>44 <span className="ci-mono text-ink-3">{t.id}</span>45 {t.acronym ? <Badge tone="outline">{t.acronym}</Badge> : null}46 <StatusBadge status={t.overall_status} />47 {t.phases.length ? <Badge>{t.phases.map(phaseLabel).join(' / ')}</Badge> : null}48 {t.has_results ? <Badge tone="ok">Results posted</Badge> : null}49 <a className="ci-link inline-flex items-center gap-1" href={`https://clinicaltrials.gov/study/${t.nct_id}`} target="_blank" rel="noopener noreferrer">50 ClinicalTrials.gov <ExternalLink className="h-3 w-3" aria-hidden />51 </a>52 <SourceBadge p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', retrievedAt: t.updated_at, ingestRunId: t.ingest_run_id, layer: 'normalized' }} />53 <ClaimBadge kind="published" />54 <GraphLink type="trial" entityRef={t.nct_id} />55 </div>56 {t.why_stopped ? <Note tone="warn">Why stopped (as posted): {t.why_stopped}</Note> : null}57 </PageHeader>5859 <div className="grid gap-8 lg:grid-cols-[1fr_340px]">60 <div className="space-y-8">61 {t.brief_summary ? (62 <Section id="summary" kicker="Summary" title="Brief summary (as posted)">63 <p className="max-w-3xl whitespace-pre-line text-[14.5px] leading-relaxed">{t.brief_summary}</p>64 </Section>65 ) : null}6667 <Section id="conditions" kicker="Conditions" title={`Conditions (${fmtInt(conditions.length || t.conditions.length)})`} description="Free-text conditions as registered, with the CancerIndex entity they were reconciled to and the match type.">68 {conditions.length ? (69 <div className="ci-table-wrap">70 <table className="ci-table">71 <thead>72 <tr>73 <th scope="col">Condition (as posted)</th>74 <th scope="col">Mapped entity</th>75 <th scope="col">Match</th>76 <th scope="col" className="num">77 Confidence78 </th>79 </tr>80 </thead>81 <tbody>82 {conditions.map((c) => (83 <tr key={c.normalized}>84 <td>{c.condition_text}</td>85 <td>86 {c.cancer_slug ? (87 <Link className="ci-link" href={`/cancer/${c.cancer_slug}/trials`}>88 {c.cancer_name}89 </Link>90 ) : (91 <span className="text-ink-3">—</span>92 )}93 </td>94 <td>95 <MatchBadge matchType={c.match_type} />96 </td>97 <td className="num">{c.confidence != null ? c.confidence.toFixed(2) : '—'}</td>98 </tr>99 ))}100 </tbody>101 </table>102 </div>103 ) : t.conditions.length ? (104 <ul className="flex flex-wrap gap-1.5 text-[13.5px]">105 {t.conditions.map((c) => (106 <li key={c} className="border border-rule px-2 py-0.5">107 {c} <MatchBadge matchType="UNRESOLVED" className="ml-1" />108 </li>109 ))}110 </ul>111 ) : (112 <EmptyState compact>No condition recorded.</EmptyState>113 )}114 </Section>115116 <Section id="interventions" kicker="Interventions" title={`Interventions (${fmtInt(interventions.length || t.interventions.length)})`}>117 {interventions.length ? (118 <div className="ci-table-wrap">119 <table className="ci-table">120 <thead>121 <tr>122 <th scope="col">Intervention</th>123 <th scope="col">Type</th>124 <th scope="col">Mapped drug</th>125 <th scope="col">Match</th>126 </tr>127 </thead>128 <tbody>129 {interventions.map((i) => (130 <tr key={i.name}>131 <td>{i.name}</td>132 <td>{i.intervention_type ? <Badge>{humanize(i.intervention_type)}</Badge> : '—'}</td>133 <td>134 {i.drug_slug ? (135 <Link className="ci-link" href={`/drug/${i.drug_slug}`}>136 {i.drug_name}137 </Link>138 ) : (139 <span className="text-ink-3">—</span>140 )}141 </td>142 <td>143 <MatchBadge matchType={i.match_type} />144 </td>145 </tr>146 ))}147 </tbody>148 </table>149 </div>150 ) : t.interventions.length ? (151 <ul className="space-y-1 text-[13.5px]">152 {t.interventions.map((i, k) => (153 <li key={`${i.name}-${k}`}>154 <Badge className="mr-1">{humanize(i.type)}</Badge> {i.name}155 {i.description ? <span className="block text-[12.5px] text-ink-3">{i.description}</span> : null}156 </li>157 ))}158 </ul>159 ) : (160 <EmptyState compact>No intervention recorded.</EmptyState>161 )}162 </Section>163164 {t.arms.length || t.primary_outcomes.length ? (165 <Section id="design" kicker="Design" title="Arms and outcomes">166 <div className="grid gap-4 md:grid-cols-2">167 <div>168 <p className="ci-kicker mb-1">Arms ({t.arms.length})</p>169 <JsonView data={t.arms} />170 </div>171 <div>172 <p className="ci-kicker mb-1">Primary outcomes ({t.primary_outcomes.length})</p>173 <JsonView data={t.primary_outcomes} />174 {t.secondary_outcomes.length ? (175 <details className="mt-2 text-[12.5px]">176 <summary className="ci-link">Secondary outcomes ({t.secondary_outcomes.length})</summary>177 <JsonView data={t.secondary_outcomes} />178 </details>179 ) : null}180 </div>181 </div>182 </Section>183 ) : null}184185 <Section id="eligibility" kicker="Eligibility" title="Eligibility (as posted)">186 <KV items={[{ k: 'Sex', v: t.sex ? humanize(t.sex) : null }, { k: 'Minimum age', v: t.minimum_age }, { k: 'Maximum age', v: t.maximum_age }]} />187 {eligText ? (188 <details className="mt-3">189 <summary className="ci-link text-[13.5px]">Show eligibility criteria text</summary>190 <pre className="ci-code mt-2 whitespace-pre-wrap">{eligText}</pre>191 </details>192 ) : Object.keys(elig).length ? (193 <details className="mt-3">194 <summary className="ci-link text-[13.5px]">Show eligibility record</summary>195 <JsonView data={elig} />196 </details>197 ) : (198 <p className="mt-2 text-[13px] text-ink-3">No eligibility text recorded.</p>199 )}200 </Section>201202 <Section id="references" kicker="References" title={`Publications (${fmtInt(pubTotal || t.references.length)})`}>203 {pubs.length ? (204 <>205 <PublicationList206 rows={pubs}207 summary={208 <>209 Showing {fmtInt(pp.from)}–{fmtInt(pp.to)} of {fmtInt(pubTotal)} indexed publications citing this registration210 </>211 }212 />213 <Pager total={pubTotal} pageSize={pp.pageSize} page={pp.page} hrefFor={(p) => `/trial/${t.nct_id}${p > 1 ? `?pPage=${p}` : ''}#references`} label="Publication pages" noun="publications" />214 </>215 ) : null}216 {t.references.length ? (217 <ul className="mt-2 space-y-1 text-[13px]">218 {t.references.map((r, i) => (219 <li key={`${r.pmid ?? i}`} className="text-ink-2">220 {r.type ? <Badge tone="outline" className="mr-1">{r.type}</Badge> : null}221 {r.citation ?? ''}222 {r.pmid ? (223 <>224 {' '}225 <Link className="ci-mono ci-link" href={`/publication/${r.pmid}`}>226 PMID {r.pmid}227 </Link>228 </>229 ) : null}230 </li>231 ))}232 </ul>233 ) : !pubs.length ? (234 <EmptyState compact>No reference posted for this study.</EmptyState>235 ) : null}236 </Section>237 </div>238239 <aside className="space-y-8">240 <Section id="dates" kicker="Registry" title="Dates and enrollment" level={3}>241 <KV242 items={[243 { k: 'Start', v: fmtDate(t.start_date) },244 { k: 'Primary completion', v: fmtDate(t.primary_completion_date) },245 { k: 'Completion', v: fmtDate(t.completion_date) },246 { k: 'First posted', v: fmtDate(t.first_posted_date) },247 { k: 'Last update posted', v: fmtDate(t.last_update_posted_date) },248 { k: 'Results first posted', v: t.results_first_posted_date ? fmtDate(t.results_first_posted_date) : null },249 { k: 'Enrollment', v: t.enrollment_count != null ? <span className="ci-num">{fmtInt(t.enrollment_count)} {t.enrollment_type ? `(${t.enrollment_type.toLowerCase()})` : ''}</span> : null },250 { k: 'Lead sponsor', v: t.lead_sponsor ? <>{t.lead_sponsor} {t.lead_sponsor_class ? <Badge tone="outline">{t.lead_sponsor_class}</Badge> : null}</> : null },251 { k: 'Collaborators', v: t.collaborators.length ? t.collaborators.join('; ') : null },252 { k: 'Keywords', v: t.keywords.length ? t.keywords.join(', ') : null },253 ]}254 />255 <Freshness dataUpdatedAt={t.updated_at} sourceUpdatedAt={t.last_update_posted_date} extra="source: clinicaltrials" />256 </Section>257 <Section id="locations" kicker="Locations" title={`Locations by country (${fmtInt(t.locations_count)})`} level={3}>258 {locations.length ? (259 <div className="ci-table-wrap">260 <table className="ci-table">261 <thead>262 <tr>263 <th scope="col">Country</th>264 <th scope="col" className="num">265 Sites266 </th>267 <th scope="col" className="num">268 Recruiting269 </th>270 </tr>271 </thead>272 <tbody>273 {locations.map((l) => (274 <tr key={l.country}>275 <td>{l.country}</td>276 <td className="num">{fmtInt(l.n)}</td>277 <td className="num">{fmtInt(l.recruiting)}</td>278 </tr>279 ))}280 </tbody>281 </table>282 </div>283 ) : t.countries.length ? (284 <p className="text-[13.5px]">{t.countries.join(', ')}</p>285 ) : (286 <p className="text-[13px] text-ink-3">No location posted.</p>287 )}288 </Section>289 <Note tone="warn">Trial listing is informational. Eligibility is decided by the study team; contact information is on ClinicalTrials.gov.</Note>290 </aside>291 </div>292 </article>293 );294}295